Skip to content

Commit

Permalink
[Scheduler] add "hashed" cron expression support
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond authored and fabpot committed Apr 25, 2023
1 parent 4b32490 commit 6ff8c28
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 4 deletions.
2 changes: 2 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
<referencedClass name="BackedEnum"/>
<referencedClass name="ReflectionIntersectionType"/>
<referencedClass name="UnitEnum"/>
<!-- These classes have been added in PHP 8.2 -->
<referencedClass name="Random\*"/>
</errorLevel>
</UndefinedClass>
<UndefinedDocblockClass>
Expand Down
10 changes: 9 additions & 1 deletion src/Symfony/Component/Scheduler/RecurringMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@ public static function every(string $frequency, object $message, string|\DateTim

public static function cron(string $expression, object $message): self
{
return new self(CronExpressionTrigger::fromSpec($expression), $message);
if (!str_contains($expression, '#')) {
return new self(CronExpressionTrigger::fromSpec($expression), $message);
}

if (!$message instanceof \Stringable) {
throw new InvalidArgumentException('A message must be stringable to use "hashed" cron expressions.');
}

return new self(CronExpressionTrigger::fromSpec($expression, (string) $message), $message);
}

public static function trigger(TriggerInterface $trigger, object $message): self
Expand Down
45 changes: 45 additions & 0 deletions src/Symfony/Component/Scheduler/Tests/RecurringMessageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Scheduler\Tests;

use PHPUnit\Framework\TestCase;
use Random\Randomizer;
use Symfony\Component\Scheduler\Exception\InvalidArgumentException;
use Symfony\Component\Scheduler\RecurringMessage;

class RecurringMessageTest extends TestCase
{
public function testCanCreateHashedCronMessage()
{
$object = new class() {
public function __toString(): string
{
return 'my task';
}
};

if (class_exists(Randomizer::class)) {
$this->assertSame('30 0 * * *', (string) RecurringMessage::cron('#midnight', $object)->getTrigger());
$this->assertSame('30 0 * * 3', (string) RecurringMessage::cron('#weekly', $object)->getTrigger());
} else {
$this->assertSame('36 0 * * *', (string) RecurringMessage::cron('#midnight', $object)->getTrigger());
$this->assertSame('36 0 * * 6', (string) RecurringMessage::cron('#weekly', $object)->getTrigger());
}
}

public function testHashedCronContextIsRequiredIfMessageIsNotStringable()
{
$this->expectException(InvalidArgumentException::class);

RecurringMessage::cron('#midnight', new \stdClass());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 Random\Randomizer;
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;

class CronExpressionTriggerTest extends TestCase
{
/**
* @dataProvider hashedExpressionProvider
*/
public function testHashedExpressionParsing(string $input, string $expected)
{
$triggerA = CronExpressionTrigger::fromSpec($input, 'my task');
$triggerB = CronExpressionTrigger::fromSpec($input, 'my task');
$triggerC = CronExpressionTrigger::fromSpec($input, 'another task');

$this->assertSame($expected, (string) $triggerA);
$this->assertSame((string) $triggerB, (string) $triggerA);
$this->assertNotSame((string) $triggerC, (string) $triggerA);
}

public static function hashedExpressionProvider(): array
{
if (class_exists(Randomizer::class)) {
return [
['# * * * *', '30 * * * *'],
['# # * * *', '30 0 * * *'],
['# # # * *', '30 0 25 * *'],
['# # # # *', '30 0 25 10 *'],
['# # # # #', '30 0 25 10 5'],
['# # 1,15 1-11 *', '30 0 1,15 1-11 *'],
['# # 1,15 * *', '30 0 1,15 * *'],
['#hourly', '30 * * * *'],
['#daily', '30 0 * * *'],
['#weekly', '30 0 * * 3'],
['#weekly@midnight', '30 0 * * 3'],
['#monthly', '30 0 25 * *'],
['#monthly@midnight', '30 0 25 * *'],
['#yearly', '30 0 25 10 *'],
['#yearly@midnight', '30 0 25 10 *'],
['#annually', '30 0 25 10 *'],
['#annually@midnight', '30 0 25 10 *'],
['#midnight', '30 0 * * *'],
['#(1-15) * * * *', '1 * * * *'],
['#(1-15) * * * #(3-5)', '1 * * * 3'],
['#(1-15) * # * #(3-5)', '1 * 17 * 5'],
];
}

return [
['# * * * *', '36 * * * *'],
['# # * * *', '36 0 * * *'],
['# # # * *', '36 0 14 * *'],
['# # # # *', '36 0 14 3 *'],
['# # # # #', '36 0 14 3 5'],
['# # 1,15 1-11 *', '36 0 1,15 1-11 *'],
['# # 1,15 * *', '36 0 1,15 * *'],
['#hourly', '36 * * * *'],
['#daily', '36 0 * * *'],
['#weekly', '36 0 * * 6'],
['#weekly@midnight', '36 0 * * 6'],
['#monthly', '36 0 14 * *'],
['#monthly@midnight', '36 0 14 * *'],
['#yearly', '36 0 14 3 *'],
['#yearly@midnight', '36 0 14 3 *'],
['#annually', '36 0 14 3 *'],
['#annually@midnight', '36 0 14 3 *'],
['#midnight', '36 0 * * *'],
['#(1-15) * * * *', '7 * * * *'],
['#(1-15) * * * #(3-5)', '7 * * * 3'],
['#(1-15) * # * #(3-5)', '7 * 1 * 5'],
];
}

public function testHashFieldsAreRandomizedIndependently()
{
$parts = explode(' ', (string) CronExpressionTrigger::fromSpec('#(1-6) #(1-6) #(1-6) #(1-6) #(1-6)', 'some context'));

$this->assertNotCount(1, array_unique($parts));
}

public function testFromHashWithStandardExpression()
{
$this->assertSame('56 20 1 9 0', (string) CronExpressionTrigger::fromSpec('56 20 1 9 0', 'some context'));
$this->assertSame('0 0 * * *', (string) CronExpressionTrigger::fromSpec('@daily'));
}
}
79 changes: 77 additions & 2 deletions src/Symfony/Component/Scheduler/Trigger/CronExpressionTrigger.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace Symfony\Component\Scheduler\Trigger;

use Cron\CronExpression;
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;
use Symfony\Component\Scheduler\Exception\LogicException;

/**
Expand All @@ -23,6 +25,27 @@
*/
final class CronExpressionTrigger implements TriggerInterface
{
private const HASH_ALIAS_MAP = [
'#hourly' => '# * * * *',
'#daily' => '# # * * *',
'#weekly' => '# # * * #',
'#weekly@midnight' => '# #(0-2) * * #',
'#monthly' => '# # # * *',
'#monthly@midnight' => '# #(0-2) # * *',
'#annually' => '# # # # *',
'#annually@midnight' => '# #(0-2) # # *',
'#yearly' => '# # # # *',
'#yearly@midnight' => '# #(0-2) # # *',
'#midnight' => '# #(0-2) * * *',
];
private const HASH_RANGES = [
[0, 59],
[0, 23],
[1, 28],
[1, 12],
[0, 6],
];

public function __construct(
private readonly CronExpression $expression = new CronExpression('* * * * *'),
) {
Expand All @@ -33,17 +56,69 @@ public function __toString(): string
return $this->expression->getExpression();
}

public static function fromSpec(string $expression = '* * * * *'): self
public static function fromSpec(string $expression = '* * * * *', string $context = null): self
{
if (!class_exists(CronExpression::class)) {
throw new LogicException(sprintf('You cannot use "%s" as the "cron expression" package is not installed. Try running "composer require dragonmantank/cron-expression".', __CLASS__));
}

return new self(new CronExpression($expression));
if (!str_contains($expression, '#')) {
return new self(new CronExpression($expression));
}

if (null === $context) {
throw new LogicException('A context must be provided to use "hashed" cron expressions.');
}

return new self(new CronExpression(self::parseHashed($expression, $context)));
}

public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable
{
return \DateTimeImmutable::createFromMutable($this->expression->getNextRunDate($run));
}

private static function parseHashed(string $expression, string $context): string
{
$expression = self::HASH_ALIAS_MAP[$expression] ?? $expression;
$parts = explode(' ', $expression);

if (5 !== \count($parts)) {
return $expression;
}

$hashEngine = self::hashEngine($context);

foreach ($parts as $position => $part) {
if (preg_match('#^\#(\((\d+)-(\d+)\))?$#', $part, $matches)) {
$parts[$position] = $hashEngine(
(int) ($matches[2] ?? self::HASH_RANGES[$position][0]),
(int) ($matches[3] ?? self::HASH_RANGES[$position][1]),
);
}
}

return implode(' ', $parts);
}

/**
* @return callable(int,int):int
*/
private static function hashEngine(string $context): callable
{
if (class_exists(Randomizer::class)) {
$randomizer = new Randomizer(new Xoshiro256StarStar(hash('sha256', $context, true)));

return static fn ($start, $end) => $randomizer->getInt($start, $end);
}

$counter = 0;

return static function ($start, $end) use ($context, &$counter) {
$possibleValues = range($start, $end);
++$counter;

return $possibleValues[(int) fmod(hexdec(substr(md5($context.'-'.$counter), 0, 10)), \count($possibleValues))];
};
}
}
2 changes: 1 addition & 1 deletion src/Symfony/Component/Scheduler/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"symfony/clock": "^6.3"
},
"require-dev": {
"dragonmantank/cron-expression": "^3",
"dragonmantank/cron-expression": "^3.1",
"symfony/cache": "^5.4|^6.0",
"symfony/dependency-injection": "^5.4|^6.0",
"symfony/lock": "^5.4|^6.0",
Expand Down

0 comments on commit 6ff8c28

Please sign in to comment.