@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

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

+273
+8
src/__phutil_library_map__.php
··· 2046 2046 'PhabricatorObjectSelectorDialog' => 'view/control/PhabricatorObjectSelectorDialog.php', 2047 2047 'PhabricatorObjectUsesCredentialsEdgeType' => 'applications/transactions/edges/PhabricatorObjectUsesCredentialsEdgeType.php', 2048 2048 'PhabricatorOffsetPagedQuery' => 'infrastructure/query/PhabricatorOffsetPagedQuery.php', 2049 + 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php', 2049 2050 'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php', 2050 2051 'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php', 2051 2052 'PhabricatorOwnersConfigOptions' => 'applications/owners/config/PhabricatorOwnersConfigOptions.php', ··· 2457 2458 'PhabricatorSubscribableInterface' => 'applications/subscriptions/interface/PhabricatorSubscribableInterface.php', 2458 2459 'PhabricatorSubscribedToObjectEdgeType' => 'applications/transactions/edges/PhabricatorSubscribedToObjectEdgeType.php', 2459 2460 'PhabricatorSubscribersQuery' => 'applications/subscriptions/query/PhabricatorSubscribersQuery.php', 2461 + 'PhabricatorSubscriptionTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php', 2460 2462 'PhabricatorSubscriptionsApplication' => 'applications/subscriptions/application/PhabricatorSubscriptionsApplication.php', 2461 2463 'PhabricatorSubscriptionsEditController' => 'applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php', 2462 2464 'PhabricatorSubscriptionsEditor' => 'applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php', ··· 2520 2522 'PhabricatorTransformedFile' => 'applications/files/storage/PhabricatorTransformedFile.php', 2521 2523 'PhabricatorTranslation' => 'infrastructure/internationalization/translation/PhabricatorTranslation.php', 2522 2524 'PhabricatorTranslationsConfigOptions' => 'applications/config/option/PhabricatorTranslationsConfigOptions.php', 2525 + 'PhabricatorTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php', 2526 + 'PhabricatorTriggerClockTestCase' => 'infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php', 2523 2527 'PhabricatorTrivialTestCase' => 'infrastructure/testing/__tests__/PhabricatorTrivialTestCase.php', 2524 2528 'PhabricatorTwitchAuthProvider' => 'applications/auth/provider/PhabricatorTwitchAuthProvider.php', 2525 2529 'PhabricatorTwitterAuthProvider' => 'applications/auth/provider/PhabricatorTwitterAuthProvider.php', ··· 5246 5250 'PhabricatorObjectRemarkupRule' => 'PhutilRemarkupRule', 5247 5251 'PhabricatorObjectUsesCredentialsEdgeType' => 'PhabricatorEdgeType', 5248 5252 'PhabricatorOffsetPagedQuery' => 'PhabricatorQuery', 5253 + 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock', 5249 5254 'PhabricatorOwnersApplication' => 'PhabricatorApplication', 5250 5255 'PhabricatorOwnersConfigOptions' => 'PhabricatorApplicationConfigOptions', 5251 5256 'PhabricatorOwnersController' => 'PhabricatorController', ··· 5711 5716 'PhabricatorStorageSetupCheck' => 'PhabricatorSetupCheck', 5712 5717 'PhabricatorSubscribedToObjectEdgeType' => 'PhabricatorEdgeType', 5713 5718 'PhabricatorSubscribersQuery' => 'PhabricatorQuery', 5719 + 'PhabricatorSubscriptionTriggerClock' => 'PhabricatorTriggerClock', 5714 5720 'PhabricatorSubscriptionsApplication' => 'PhabricatorApplication', 5715 5721 'PhabricatorSubscriptionsEditController' => 'PhabricatorController', 5716 5722 'PhabricatorSubscriptionsEditor' => 'PhabricatorEditor', ··· 5772 5778 'PhabricatorTransactionsApplication' => 'PhabricatorApplication', 5773 5779 'PhabricatorTransformedFile' => 'PhabricatorFileDAO', 5774 5780 'PhabricatorTranslationsConfigOptions' => 'PhabricatorApplicationConfigOptions', 5781 + 'PhabricatorTriggerClock' => 'Phobject', 5782 + 'PhabricatorTriggerClockTestCase' => 'PhabricatorTestCase', 5775 5783 'PhabricatorTrivialTestCase' => 'PhabricatorTestCase', 5776 5784 'PhabricatorTwitchAuthProvider' => 'PhabricatorOAuth2AuthProvider', 5777 5785 'PhabricatorTwitterAuthProvider' => 'PhabricatorOAuth1AuthProvider',
+25
src/infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php
··· 1 + <?php 2 + 3 + /** 4 + * Triggers an event exactly once, at a specific epoch time. 5 + */ 6 + final class PhabricatorOneTimeTriggerClock 7 + extends PhabricatorTriggerClock { 8 + 9 + public function validateProperties(array $properties) { 10 + PhutilTypeSpec::checkMap( 11 + $properties, 12 + array( 13 + 'epoch' => 'int', 14 + )); 15 + } 16 + 17 + public function getNextEventEpoch($last_epoch, $is_reschedule) { 18 + if ($last_epoch) { 19 + return null; 20 + } 21 + 22 + return $this->getProperty('epoch'); 23 + } 24 + 25 + }
+81
src/infrastructure/daemon/workers/clock/PhabricatorSubscriptionTriggerClock.php
··· 1 + <?php 2 + 3 + /** 4 + * Triggers an event every month on the same day of the month, like the 12th 5 + * of the month. 6 + * 7 + * If a given month does not have such a day (for instance, the clock triggers 8 + * on the 30th of each month and the month in question is February, which never 9 + * has a 30th day), it will trigger on the last day of the month instead. 10 + * 11 + * Choosing this strategy for subscriptions is predictable (it's easy to 12 + * anticipate when a subscription period will end) and fair (billing 13 + * periods always have nearly equal length). It also spreads subscriptions 14 + * out evenly. If there are issues with billing, this provides an opportunity 15 + * for them to be corrected after only a few customers are affected, instead of 16 + * (for example) having every subscription fail all at once on the 1st of the 17 + * month. 18 + */ 19 + final class PhabricatorSubscriptionTriggerClock 20 + extends PhabricatorTriggerClock { 21 + 22 + public function validateProperties(array $properties) { 23 + PhutilTypeSpec::checkMap( 24 + $properties, 25 + array( 26 + 'start' => 'int', 27 + )); 28 + } 29 + 30 + public function getNextEventEpoch($last_epoch, $is_reschedule) { 31 + $start_epoch = $this->getProperty('start'); 32 + if (!$last_epoch) { 33 + $last_epoch = $start_epoch; 34 + } 35 + 36 + // Constructing DateTime objects like this implies UTC, so we don't need 37 + // to set that explicitly. 38 + $start = new DateTime('@'.$start_epoch); 39 + $last = new DateTime('@'.$last_epoch); 40 + 41 + $year = (int)$last->format('Y'); 42 + $month = (int)$last->format('n'); 43 + 44 + // Note that we're getting the day of the month from the start date, not 45 + // from the last event date. This lets us schedule on March 31 after moving 46 + // the date back to Feb 28. 47 + $day = (int)$start->format('j'); 48 + 49 + // We trigger at the same time of day as the original event. Generally, 50 + // this means that you should get invoiced at a reasonable local time in 51 + // most cases, unless you subscribed at 1AM or something. 52 + $hms = $start->format('G:i:s'); 53 + 54 + // Increment the month by 1. 55 + $month = $month + 1; 56 + 57 + // If we ran off the end of the calendar, set the month back to January 58 + // and increment the year by 1. 59 + if ($month > 12) { 60 + $month = 1; 61 + $year = $year + 1; 62 + } 63 + 64 + // Now, move the day backward until it falls in the correct month. If we 65 + // pass an invalid date like "2014-2-31", it will internally be parsed 66 + // as though we had passed "2014-3-3". 67 + while (true) { 68 + $next = new DateTime("{$year}-{$month}-{$day} {$hms} UTC"); 69 + if ($next->format('n') == $month) { 70 + // The month didn't get corrected forward, so we're all set. 71 + break; 72 + } else { 73 + // The month did get corrected forward, so back off a day. 74 + $day--; 75 + } 76 + } 77 + 78 + return (int)$next->format('U'); 79 + } 80 + 81 + }
+74
src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
··· 1 + <?php 2 + 3 + /** 4 + * A trigger clock implements scheduling rules for an event. 5 + * 6 + * Two examples of triggered events are a subscription which bills on the 12th 7 + * of every month, or a meeting reminder which sends an email 15 minutes before 8 + * an event. A trigger clock contains the logic to figure out exactly when 9 + * those times are. 10 + * 11 + * For example, it might schedule an event every hour, or every Thursday, or on 12 + * the 15th of every month at 3PM, or only at a specific time. 13 + */ 14 + abstract class PhabricatorTriggerClock extends Phobject { 15 + 16 + private $properties; 17 + 18 + public function __construct(array $properties) { 19 + $this->validateProperties($properties); 20 + $this->properties = $properties; 21 + } 22 + 23 + public function getProperties() { 24 + return $this->properties; 25 + } 26 + 27 + public function getProperty($key, $default = null) { 28 + return idx($this->properties, $key, $default); 29 + } 30 + 31 + 32 + /** 33 + * Validate clock configuration. 34 + * 35 + * @param map<string, wild> Map of clock properties. 36 + * @return void 37 + */ 38 + abstract public function validateProperties(array $properties); 39 + 40 + 41 + /** 42 + * Get the next occurrence of this event. 43 + * 44 + * This method takes two parameters: the last time this event occurred (or 45 + * null if it has never triggered before) and a flag distinguishing between 46 + * a normal reschedule (after a successful trigger) or an update because of 47 + * a trigger change. 48 + * 49 + * If this event does not occur again, return `null` to stop it from being 50 + * rescheduled. For example, a meeting reminder may be sent only once before 51 + * the meeting. 52 + * 53 + * If this event does occur again, return the epoch timestamp of the next 54 + * occurrence. 55 + * 56 + * When performing routine reschedules, the event must move forward in time: 57 + * any timestamp you return must be later than the last event. For instance, 58 + * if this event triggers an invoice, the next invoice date must be after 59 + * the previous invoice date. This prevents an event from looping more than 60 + * once per second. 61 + * 62 + * In contrast, after an update (not a routine reschedule), the next event 63 + * may be scheduled at any time. For example, if a meeting is moved from next 64 + * week to 3 minutes from now, the clock may reschedule the notification to 65 + * occur 12 minutes ago. This will cause it to execute immediately. 66 + * 67 + * @param int|null Last time the event occurred, or null if it has never 68 + * triggered before. 69 + * @param bool True if this is a reschedule after a successful trigger. 70 + * @return int|null Next event, or null to decline to reschedule. 71 + */ 72 + abstract public function getNextEventEpoch($last_epoch, $is_reschedule); 73 + 74 + }
+85
src/infrastructure/daemon/workers/clock/__tests__/PhabricatorTriggerClockTestCase.php
··· 1 + <?php 2 + 3 + final class PhabricatorTriggerClockTestCase extends PhabricatorTestCase { 4 + 5 + public function testOneTimeTriggerClock() { 6 + $now = PhabricatorTime::getNow(); 7 + 8 + $clock = new PhabricatorOneTimeTriggerClock( 9 + array( 10 + 'epoch' => $now, 11 + )); 12 + 13 + $this->assertEqual( 14 + $now, 15 + $clock->getNextEventEpoch(null, false), 16 + pht('Should trigger at specified epoch.')); 17 + 18 + $this->assertEqual( 19 + null, 20 + $clock->getNextEventEpoch(1, false), 21 + pht('Should trigger only once.')); 22 + } 23 + 24 + public function testSubscriptionTriggerClock() { 25 + $start = strtotime('2014-01-31 2:34:56 UTC'); 26 + 27 + $clock = new PhabricatorSubscriptionTriggerClock( 28 + array( 29 + 'start' => $start, 30 + )); 31 + 32 + $expect_list = array( 33 + // This should be moved to the 28th of February. 34 + '2014-02-28 2:34:56', 35 + 36 + // In March, which has 31 days, it should move back to the 31st. 37 + '2014-03-31 2:34:56', 38 + 39 + // On months with only 30 days, it should occur on the 30th. 40 + '2014-04-30 2:34:56', 41 + '2014-05-31 2:34:56', 42 + '2014-06-30 2:34:56', 43 + '2014-07-31 2:34:56', 44 + '2014-08-31 2:34:56', 45 + '2014-09-30 2:34:56', 46 + '2014-10-31 2:34:56', 47 + '2014-11-30 2:34:56', 48 + '2014-12-31 2:34:56', 49 + 50 + // After billing on Dec 31 2014, it should wrap around to Jan 31 2015. 51 + '2015-01-31 2:34:56', 52 + '2015-02-28 2:34:56', 53 + '2015-03-31 2:34:56', 54 + '2015-04-30 2:34:56', 55 + '2015-05-31 2:34:56', 56 + '2015-06-30 2:34:56', 57 + '2015-07-31 2:34:56', 58 + '2015-08-31 2:34:56', 59 + '2015-09-30 2:34:56', 60 + '2015-10-31 2:34:56', 61 + '2015-11-30 2:34:56', 62 + '2015-12-31 2:34:56', 63 + '2016-01-31 2:34:56', 64 + 65 + // Finally, this should bill on leap day in 2016. 66 + '2016-02-29 2:34:56', 67 + '2016-03-31 2:34:56', 68 + ); 69 + 70 + $last_epoch = null; 71 + foreach ($expect_list as $cycle => $expect) { 72 + $next_epoch = $clock->getNextEventEpoch( 73 + $last_epoch, 74 + ($last_epoch !== null)); 75 + 76 + $this->assertEqual( 77 + $expect, 78 + id(new DateTime('@'.$next_epoch))->format('Y-m-d g:i:s'), 79 + pht('Billing cycle %s.', $cycle)); 80 + 81 + $last_epoch = $next_epoch; 82 + } 83 + } 84 + 85 + }