From ebcced6487b1bd9eccc375a4dde5490c84959ffb Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 25 Mar 2023 19:10:21 +0100 Subject: [PATCH] [Scheduler] Improve triggers performance when possible --- .../Component/Scheduler/RecurringMessage.php | 20 +- .../Tests/Trigger/AbstractTriggerTestCase.php | 53 ---- .../Tests/Trigger/DateIntervalTriggerTest.php | 136 ---------- .../Tests/Trigger/DatePeriodTriggerTest.php | 66 ----- .../Tests/Trigger/PeriodicalTriggerTest.php | 250 ++++++++++++++++++ .../Scheduler/Trigger/DateIntervalTrigger.php | 50 ---- .../Scheduler/Trigger/DatePeriodTrigger.php | 46 ---- .../Scheduler/Trigger/PeriodicalTrigger.php | 136 ++++++++++ 8 files changed, 398 insertions(+), 359 deletions(-) delete mode 100644 src/Symfony/Component/Scheduler/Tests/Trigger/AbstractTriggerTestCase.php delete mode 100644 src/Symfony/Component/Scheduler/Tests/Trigger/DateIntervalTriggerTest.php delete mode 100644 src/Symfony/Component/Scheduler/Tests/Trigger/DatePeriodTriggerTest.php create mode 100644 src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php delete mode 100644 src/Symfony/Component/Scheduler/Trigger/DateIntervalTrigger.php delete mode 100644 src/Symfony/Component/Scheduler/Trigger/DatePeriodTrigger.php create mode 100644 src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php diff --git a/src/Symfony/Component/Scheduler/RecurringMessage.php b/src/Symfony/Component/Scheduler/RecurringMessage.php index e802970e8de5..ed0badb8503d 100644 --- a/src/Symfony/Component/Scheduler/RecurringMessage.php +++ b/src/Symfony/Component/Scheduler/RecurringMessage.php @@ -13,8 +13,8 @@ use Symfony\Component\Scheduler\Exception\InvalidArgumentException; use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger; -use Symfony\Component\Scheduler\Trigger\DateIntervalTrigger; use Symfony\Component\Scheduler\Trigger\JitterTrigger; +use Symfony\Component\Scheduler\Trigger\PeriodicalTrigger; use Symfony\Component\Scheduler\Trigger\TriggerInterface; /** @@ -31,17 +31,21 @@ private function __construct( } /** - * Uses a relative date format to define the frequency. + * Sets the trigger frequency. * + * Supported frequency formats: + * + * * An integer to define the frequency as a number of seconds; + * * An ISO 8601 duration format; + * * A relative date format as supported by \DateInterval; + * * A \DateInterval instance. + * + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations * @see https://php.net/datetime.formats.relative */ - public static function every(string $frequency, object $message, string|\DateTimeImmutable $from = new \DateTimeImmutable(), string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01')): self + public static function every(string|int|\DateInterval $frequency, object $message, string|\DateTimeImmutable $from = new \DateTimeImmutable(), string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01')): self { - if (false === $interval = \DateInterval::createFromDateString($frequency)) { - throw new InvalidArgumentException(sprintf('Frequency "%s" cannot be parsed.', $frequency)); - } - - return new self(new DateIntervalTrigger($interval, $from, $until), $message); + return new self(new PeriodicalTrigger($frequency, $from, $until), $message); } public static function cron(string $expression, object $message): self diff --git a/src/Symfony/Component/Scheduler/Tests/Trigger/AbstractTriggerTestCase.php b/src/Symfony/Component/Scheduler/Tests/Trigger/AbstractTriggerTestCase.php deleted file mode 100644 index ee996e23d1d2..000000000000 --- a/src/Symfony/Component/Scheduler/Tests/Trigger/AbstractTriggerTestCase.php +++ /dev/null @@ -1,53 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Scheduler\Tests\Trigger; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\Scheduler\Trigger\DatePeriodTrigger; -use Symfony\Component\Scheduler\Trigger\TriggerInterface; - -abstract class AbstractTriggerTestCase extends TestCase -{ - /** - * @dataProvider providerGetNextRunDate - */ - public function testGetNextRunDate(\DateTimeImmutable $from, TriggerInterface $trigger, array $expected) - { - $this->assertEquals($expected, $this->getNextRunDates($from, $trigger)); - } - - abstract public static function providerGetNextRunDate(): iterable; - - protected static function createTrigger(string $interval): DatePeriodTrigger - { - return new DatePeriodTrigger( - new \DatePeriod(new \DateTimeImmutable('13:45'), \DateInterval::createFromDateString($interval), new \DateTimeImmutable('2023-06-19')) - ); - } - - private function getNextRunDates(\DateTimeImmutable $from, TriggerInterface $trigger): array - { - $dates = []; - $i = 0; - $next = $from; - while ($i++ < 20) { - $next = $trigger->getNextRunDate($next); - if (!$next) { - break; - } - - $dates[] = $next; - } - - return $dates; - } -} diff --git a/src/Symfony/Component/Scheduler/Tests/Trigger/DateIntervalTriggerTest.php b/src/Symfony/Component/Scheduler/Tests/Trigger/DateIntervalTriggerTest.php deleted file mode 100644 index 5b1dccd6db58..000000000000 --- a/src/Symfony/Component/Scheduler/Tests/Trigger/DateIntervalTriggerTest.php +++ /dev/null @@ -1,136 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Scheduler\Tests\Trigger; - -use Symfony\Component\Scheduler\Exception\InvalidArgumentException; -use Symfony\Component\Scheduler\Trigger\DateIntervalTrigger; -use Symfony\Component\Scheduler\Trigger\DatePeriodTrigger; - -class DateIntervalTriggerTest extends DatePeriodTriggerTest -{ - /** - * @dataProvider provideForConstructor - */ - public function testConstructor(DateIntervalTrigger $trigger) - { - $run = new \DateTimeImmutable('2222-02-22 13:34:00'); - - $this->assertSame('2222-02-23 13:34:00', $trigger->getNextRunDate($run)->format('Y-m-d H:i:s')); - } - - public static function provideForConstructor(): iterable - { - $from = new \DateTimeImmutable($now = '2222-02-22 13:34:00'); - $until = new \DateTimeImmutable($farFuture = '3000-01-01'); - $day = new \DateInterval('P1D'); - - return [ - [new DateIntervalTrigger(86400, $from, $until)], - [new DateIntervalTrigger('86400', $from, $until)], - [new DateIntervalTrigger('P1D', $from, $until)], - [new DateIntervalTrigger($day, $now, $farFuture)], - [new DateIntervalTrigger($day, $now)], - ]; - } - - /** - * @dataProvider getInvalidIntervals - */ - public function testInvalidInterval($interval) - { - $this->expectException(InvalidArgumentException::class); - - new DateIntervalTrigger($interval, $now = new \DateTimeImmutable(), $now->modify('1 day')); - } - - public static function getInvalidIntervals(): iterable - { - yield ['wrong']; - yield ['3600.5']; - yield [-3600]; - } - - /** - * @dataProvider providerGetNextRunDateAgain - */ - public function testGetNextRunDateAgain(DateIntervalTrigger $trigger, \DateTimeImmutable $lastRun, ?\DateTimeImmutable $expected) - { - $this->assertEquals($expected, $trigger->getNextRunDate($lastRun)); - } - - public static function providerGetNextRunDateAgain(): iterable - { - $trigger = new DateIntervalTrigger( - 600, - new \DateTimeImmutable('2020-02-20T02:00:00+02'), - new \DateTimeImmutable('2020-02-20T03:00:00+02') - ); - - yield [ - $trigger, - new \DateTimeImmutable('@0'), - new \DateTimeImmutable('2020-02-20T02:00:00+02'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T01:59:59.999999+02'), - new \DateTimeImmutable('2020-02-20T02:00:00+02'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T02:00:00+02'), - new \DateTimeImmutable('2020-02-20T02:10:00+02'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T02:05:00+02'), - new \DateTimeImmutable('2020-02-20T02:10:00+02'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T02:49:59.999999+02'), - new \DateTimeImmutable('2020-02-20T02:50:00+02'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T02:50:00+02'), - null, - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T03:00:00+02'), - null, - ]; - - $trigger = new DateIntervalTrigger( - 600, - new \DateTimeImmutable('2020-02-20T02:00:00Z'), - new \DateTimeImmutable('2020-02-20T03:01:00Z') - ); - - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T02:59:59.999999Z'), - new \DateTimeImmutable('2020-02-20T03:00:00Z'), - ]; - yield [ - $trigger, - new \DateTimeImmutable('2020-02-20T03:00:00Z'), - null, - ]; - } - - protected static function createTrigger(string $interval): DatePeriodTrigger - { - return new DateIntervalTrigger($interval, '2023-03-19 13:45', '2023-06-19'); - } -} diff --git a/src/Symfony/Component/Scheduler/Tests/Trigger/DatePeriodTriggerTest.php b/src/Symfony/Component/Scheduler/Tests/Trigger/DatePeriodTriggerTest.php deleted file mode 100644 index f538eb67c4be..000000000000 --- a/src/Symfony/Component/Scheduler/Tests/Trigger/DatePeriodTriggerTest.php +++ /dev/null @@ -1,66 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Scheduler\Tests\Trigger; - -use Symfony\Component\Scheduler\Trigger\DatePeriodTrigger; - -class DatePeriodTriggerTest extends AbstractTriggerTestCase -{ - public static function providerGetNextRunDate(): iterable - { - yield [ - new \DateTimeImmutable('2023-03-19 13:45'), - self::createTrigger('next tuesday'), - [ - new \DateTimeImmutable('2023-03-21 13:45:00'), - new \DateTimeImmutable('2023-03-28 13:45:00'), - new \DateTimeImmutable('2023-04-04 13:45:00'), - new \DateTimeImmutable('2023-04-11 13:45:00'), - new \DateTimeImmutable('2023-04-18 13:45:00'), - new \DateTimeImmutable('2023-04-25 13:45:00'), - new \DateTimeImmutable('2023-05-02 13:45:00'), - new \DateTimeImmutable('2023-05-09 13:45:00'), - new \DateTimeImmutable('2023-05-16 13:45:00'), - new \DateTimeImmutable('2023-05-23 13:45:00'), - new \DateTimeImmutable('2023-05-30 13:45:00'), - new \DateTimeImmutable('2023-06-06 13:45:00'), - new \DateTimeImmutable('2023-06-13 13:45:00'), - ], - ]; - - yield [ - new \DateTimeImmutable('2023-03-19 13:45'), - self::createTrigger('last day of next month'), - [ - new \DateTimeImmutable('2023-04-30 13:45:00'), - new \DateTimeImmutable('2023-05-31 13:45:00'), - ], - ]; - - yield [ - new \DateTimeImmutable('2023-03-19 13:45'), - self::createTrigger('first monday of next month'), - [ - new \DateTimeImmutable('2023-04-03 13:45:00'), - new \DateTimeImmutable('2023-05-01 13:45:00'), - new \DateTimeImmutable('2023-06-05 13:45:00'), - ], - ]; - } - - protected static function createTrigger(string $interval): DatePeriodTrigger - { - return new DatePeriodTrigger( - new \DatePeriod(new \DateTimeImmutable('2023-03-19 13:45'), \DateInterval::createFromDateString($interval), new \DateTimeImmutable('2023-06-19')), - ); - } -} diff --git a/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php b/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php new file mode 100644 index 000000000000..ceaf3dc81f68 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Tests/Trigger/PeriodicalTriggerTest.php @@ -0,0 +1,250 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Tests\Trigger; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Scheduler\Exception\InvalidArgumentException; +use Symfony\Component\Scheduler\Trigger\PeriodicalTrigger; +use Symfony\Component\Scheduler\Trigger\TriggerInterface; + +class PeriodicalTriggerTest extends TestCase +{ + /** + * @dataProvider provideForConstructor + */ + public function testConstructor(PeriodicalTrigger $trigger, bool $optimizable = true) + { + $run = new \DateTimeImmutable('2922-02-22 13:34:00+00:00'); + + $this->assertSame('2922-02-23 13:34:00+00:00', $trigger->getNextRunDate($run)->format('Y-m-d H:i:sP')); + + if ($optimizable) { + // test that we are using the fast algorithm for short period of time + $p = new \ReflectionProperty($trigger, 'intervalInSeconds'); + $p->setAccessible(true); + $this->assertNotSame(0, $p->getValue($trigger)); + } + } + + public static function provideForConstructor(): iterable + { + $from = new \DateTimeImmutable($now = '2022-02-22 13:34:00+00:00'); + $until = new \DateTimeImmutable($farFuture = '3000-01-01'); + + yield [new PeriodicalTrigger(86400, $from, $until)]; + yield [new PeriodicalTrigger('86400', $from, $until)]; + yield [new PeriodicalTrigger('1 day', $from, $until), false]; + yield [new PeriodicalTrigger('24 hours', $from, $until)]; + yield [new PeriodicalTrigger('1440 minutes', $from, $until)]; + yield [new PeriodicalTrigger('86400 seconds', $from, $until)]; + yield [new PeriodicalTrigger('1day', $from, $until), false]; + yield [new PeriodicalTrigger('24hours', $from, $until)]; + yield [new PeriodicalTrigger('1440minutes', $from, $until)]; + yield [new PeriodicalTrigger('86400seconds', $from, $until)]; + yield [new PeriodicalTrigger('P1D', $from, $until), false]; + yield [new PeriodicalTrigger('PT24H', $from, $until)]; + yield [new PeriodicalTrigger('PT1440M', $from, $until)]; + yield [new PeriodicalTrigger('PT86400S', $from, $until)]; + yield [new PeriodicalTrigger(new \DateInterval('P1D'), $now, $farFuture), false]; + yield [new PeriodicalTrigger(new \DateInterval('P1D'), $now), false]; + } + + /** + * @dataProvider getInvalidIntervals + */ + public function testInvalidInterval($interval) + { + $this->expectException(InvalidArgumentException::class); + + new PeriodicalTrigger($interval, $now = new \DateTimeImmutable(), $now->modify('1 day')); + } + + public static function getInvalidIntervals(): iterable + { + yield ['wrong']; + yield ['3600.5']; + yield ['-3600']; + yield [-3600]; + } + + /** + * @dataProvider provideForToString + */ + public function testToString(string $expected, PeriodicalTrigger $trigger) + { + $this->assertSame($expected, (string) $trigger); + } + + public static function provideForToString() + { + $from = new \DateTimeImmutable('2022-02-22 13:34:00+00:00'); + $until = new \DateTimeImmutable('3000-01-01'); + + yield ['every 20 seconds', new PeriodicalTrigger(20, $from, $until)]; + yield ['every 20 seconds', new PeriodicalTrigger('20', $from, $until)]; + yield ['every 2 seconds (PT2S)', new PeriodicalTrigger('PT2S', $from, $until)]; + yield ['every 20 seconds', new PeriodicalTrigger('20 seconds', $from, $until)]; + yield ['every 4 minutes 20 seconds', new PeriodicalTrigger('4 minutes 20 seconds', $from, $until)]; + yield ['every 2 hours', new PeriodicalTrigger('2 hours', $from, $until)]; + yield ['every 2 seconds', new PeriodicalTrigger(new \DateInterval('PT2S'), $from, $until)]; + yield ['DateInterval', new PeriodicalTrigger(new \DateInterval('P1D'), $from, $until)]; + + if (\PHP_VERSION_ID >= 80200) { + yield ['last day of next month', new PeriodicalTrigger(\DateInterval::createFromDateString('last day of next month'), $from, $until)]; + } + } + + /** + * @dataProvider providerGetNextRunDates + */ + public function testGetNextRunDates(\DateTimeImmutable $from, TriggerInterface $trigger, array $expected, int $count = 0) + { + $this->assertEquals($expected, $this->getNextRunDates($from, $trigger, $count ?? \count($expected))); + } + + public static function providerGetNextRunDates(): iterable + { + yield [ + new \DateTimeImmutable('2023-03-19 13:45'), + self::createTrigger('next tuesday'), + [ + new \DateTimeImmutable('2023-03-21 13:45:00'), + new \DateTimeImmutable('2023-03-28 13:45:00'), + new \DateTimeImmutable('2023-04-04 13:45:00'), + new \DateTimeImmutable('2023-04-11 13:45:00'), + new \DateTimeImmutable('2023-04-18 13:45:00'), + new \DateTimeImmutable('2023-04-25 13:45:00'), + new \DateTimeImmutable('2023-05-02 13:45:00'), + new \DateTimeImmutable('2023-05-09 13:45:00'), + new \DateTimeImmutable('2023-05-16 13:45:00'), + new \DateTimeImmutable('2023-05-23 13:45:00'), + new \DateTimeImmutable('2023-05-30 13:45:00'), + new \DateTimeImmutable('2023-06-06 13:45:00'), + new \DateTimeImmutable('2023-06-13 13:45:00'), + ], + 20, + ]; + + yield [ + new \DateTimeImmutable('2023-03-19 13:45'), + self::createTrigger('last day of next month'), + [ + new \DateTimeImmutable('2023-04-30 13:45:00'), + new \DateTimeImmutable('2023-05-31 13:45:00'), + ], + 20, + ]; + + yield [ + new \DateTimeImmutable('2023-03-19 13:45'), + self::createTrigger('first monday of next month'), + [ + new \DateTimeImmutable('2023-04-03 13:45:00'), + new \DateTimeImmutable('2023-05-01 13:45:00'), + new \DateTimeImmutable('2023-06-05 13:45:00'), + ], + 20, + ]; + } + + /** + * @dataProvider providerGetNextRunDateAgain + */ + public function testGetNextRunDateAgain(PeriodicalTrigger $trigger, \DateTimeImmutable $lastRun, ?\DateTimeImmutable $expected) + { + $this->assertEquals($expected, $trigger->getNextRunDate($lastRun)); + } + + public static function providerGetNextRunDateAgain(): iterable + { + $trigger = new PeriodicalTrigger( + 600, + new \DateTimeImmutable('2020-02-20T02:00:00+02:00'), + new \DateTimeImmutable('2020-02-20T03:00:00+02:00') + ); + + yield [ + $trigger, + new \DateTimeImmutable('@0'), + new \DateTimeImmutable('2020-02-20T02:00:00+02:00'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T01:59:59.999999+02:00'), + new \DateTimeImmutable('2020-02-20T02:00:00+02:00'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T02:00:00+02:00'), + new \DateTimeImmutable('2020-02-20T02:10:00+02:00'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T02:05:00+02:00'), + new \DateTimeImmutable('2020-02-20T02:10:00+02:00'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T02:49:59.999999+02:00'), + new \DateTimeImmutable('2020-02-20T02:50:00+02:00'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T02:50:00+02:00'), + null, + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T03:00:00+02:00'), + null, + ]; + + $trigger = new PeriodicalTrigger( + 600, + new \DateTimeImmutable('2020-02-20T02:00:00Z'), + new \DateTimeImmutable('2020-02-20T03:01:00Z') + ); + + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T02:59:59.999999Z'), + new \DateTimeImmutable('2020-02-20T03:00:00Z'), + ]; + yield [ + $trigger, + new \DateTimeImmutable('2020-02-20T03:00:00Z'), + null, + ]; + } + + private static function createTrigger(string|int|\DateInterval $interval): PeriodicalTrigger + { + return new PeriodicalTrigger($interval, '2023-03-19 13:45', '2023-06-19'); + } + + private function getNextRunDates(\DateTimeImmutable $from, TriggerInterface $trigger, int $count = 1): array + { + $dates = []; + $i = 0; + $next = $from; + while ($i++ < $count) { + $next = $trigger->getNextRunDate($next); + if (!$next) { + break; + } + + $dates[] = $next; + } + + return $dates; + } +} diff --git a/src/Symfony/Component/Scheduler/Trigger/DateIntervalTrigger.php b/src/Symfony/Component/Scheduler/Trigger/DateIntervalTrigger.php deleted file mode 100644 index 6d71a8ed8383..000000000000 --- a/src/Symfony/Component/Scheduler/Trigger/DateIntervalTrigger.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Scheduler\Trigger; - -use Symfony\Component\Scheduler\Exception\InvalidArgumentException; - -/** - * @experimental - */ -class DateIntervalTrigger extends DatePeriodTrigger -{ - public function __construct( - string|int|\DateInterval $interval, - string|\DateTimeImmutable $from = new \DateTimeImmutable(), - string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01'), - ) { - if (\is_string($from)) { - $from = new \DateTimeImmutable($from); - } - if (\is_string($until)) { - $until = new \DateTimeImmutable($until); - } - try { - if (\is_int($interval)) { - $interval = new \DateInterval('PT'.$interval.'S'); - } elseif (\is_string($interval)) { - if ('P' === ($interval[0] ?? '')) { - $interval = new \DateInterval($interval); - } elseif (ctype_digit($interval)) { - $interval = new \DateInterval('PT'.$interval.'S'); - } else { - $interval = \DateInterval::createFromDateString($interval); - } - } - } catch (\Exception $e) { - throw new InvalidArgumentException(sprintf('Invalid interval "%s": ', $interval).$e->getMessage(), 0, $e); - } - - parent::__construct(new \DatePeriod($from, $interval, $until)); - } -} diff --git a/src/Symfony/Component/Scheduler/Trigger/DatePeriodTrigger.php b/src/Symfony/Component/Scheduler/Trigger/DatePeriodTrigger.php deleted file mode 100644 index a292fcea1efe..000000000000 --- a/src/Symfony/Component/Scheduler/Trigger/DatePeriodTrigger.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Scheduler\Trigger; - -/** - * @experimental - */ -class DatePeriodTrigger implements TriggerInterface -{ - public function __construct( - private readonly \DatePeriod $period, - ) { - } - - public function __toString(): string - { - return sprintf( - '%s - %s, %s', - $this->period->getStartDate()->format(\DateTimeInterface::ATOM), - $this->period->getEndDate()?->format(\DateTimeInterface::ATOM) ?? '(inf)', - '-', - ); - } - - public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable - { - $iterator = $this->period->getIterator(); - while ($run >= $next = $iterator->current()) { - $iterator->next(); - if (!$iterator->valid()) { - return null; - } - } - - return $next; - } -} diff --git a/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php b/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php new file mode 100644 index 000000000000..cfda8c7544c5 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Trigger/PeriodicalTrigger.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Trigger; + +use Symfony\Component\Scheduler\Exception\InvalidArgumentException; + +/** + * @experimental + */ +class PeriodicalTrigger implements TriggerInterface, \Stringable +{ + private float $intervalInSeconds = 0.0; + private \DateTimeImmutable $from; + private \DateTimeImmutable $until; + private \DatePeriod $period; + private string $description; + + public function __construct( + string|int|float|\DateInterval $interval, + string|\DateTimeImmutable $from = new \DateTimeImmutable(), + string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01'), + ) { + $this->from = \is_string($from) ? new \DateTimeImmutable($from) : $from; + $this->until = \is_string($until) ? new \DateTimeImmutable($until) : $until; + + if (\is_int($interval) || \is_float($interval)) { + if (0 >= $interval) { + throw new InvalidArgumentException('The "$interval" argument must be greater than zero.'); + } + + $this->intervalInSeconds = $interval; + $this->description = sprintf('every %d seconds', $this->intervalInSeconds); + + return; + } + + if (\is_string($interval) && ctype_digit($interval)) { + $this->intervalInSeconds = (int) $interval; + $this->description = sprintf('every %d seconds', $this->intervalInSeconds); + + return; + } + + try { + if (\is_string($interval) && 'P' === ($interval[0] ?? '')) { + $this->intervalInSeconds = $this->calcInterval(new \DateInterval($interval)); + $this->description = sprintf('every %d seconds (%s)', $this->intervalInSeconds, $interval); + + return; + } + + $i = $interval; + if (\is_string($interval)) { + $this->description = sprintf('every %s', $interval); + $i = \DateInterval::createFromDateString($interval); + } else { + $a = (array) $interval; + $this->description = \PHP_VERSION_ID >= 80200 && $a['from_string'] ? $a['date_string'] : 'DateInterval'; + } + + if ($this->canBeConvertedToSeconds($i)) { + $this->intervalInSeconds = $this->calcInterval($i); + if ('DateInterval' === $this->description) { + $this->description = sprintf('every %s seconds', $this->intervalInSeconds); + } + } else { + $this->period = new \DatePeriod($this->from, $i, $this->until); + } + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('Invalid interval "%s": ', $interval instanceof \DateInterval ? 'instance of \DateInterval' : $interval).$e->getMessage(), 0, $e); + } + } + + public function __toString(): string + { + return $this->description; + } + + public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable + { + if ($this->intervalInSeconds) { + if ($this->from > $run) { + return $this->from; + } + if ($this->until <= $run) { + return null; + } + + $from = $this->from->format('U.u'); + $delta = $run->format('U.u') - $from; + $recurrencesPassed = floor($delta / $this->intervalInSeconds); + $nextRunTimestamp = sprintf('%.6F', ($recurrencesPassed + 1) * $this->intervalInSeconds + $from); + $nextRun = \DateTimeImmutable::createFromFormat('U.u', $nextRunTimestamp, $this->from->getTimezone()); + + return $this->until > $nextRun ? $nextRun : null; + } + + $iterator = $this->period->getIterator(); + while ($run >= $next = $iterator->current()) { + $iterator->next(); + if (!$iterator->valid()) { + return null; + } + } + + return $next; + } + + private function canBeConvertedToSeconds(\DateInterval $interval): bool + { + $a = (array) $interval; + if (\PHP_VERSION_ID >= 80200) { + if ($a['from_string']) { + return preg_match('#^\s*\d+\s*(sec|second|min|minute|hour)s?\s*$#', $a['date_string']); + } + } elseif ($a['weekday'] || $a['weekday_behavior'] || $a['first_last_day_of'] || $a['days'] || $a['special_type'] || $a['special_amount'] || $a['have_weekday_relative'] || $a['have_special_relative']) { + return false; + } + + return !$interval->y && !$interval->m && !$interval->d; + } + + private function calcInterval(\DateInterval $interval): float + { + return $this->from->setTimestamp(0)->add($interval)->format('U.u'); + } +}