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 committed Mar 24, 2023
1 parent aadd302 commit 76316a7
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/Symfony/Component/Scheduler/RecurringMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Component\Scheduler\Exception\InvalidArgumentException;
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;
use Symfony\Component\Scheduler\Trigger\DateIntervalTrigger;
use Symfony\Component\Scheduler\Trigger\HashedCronExpressionTrigger;
use Symfony\Component\Scheduler\Trigger\TriggerInterface;

/**
Expand Down Expand Up @@ -46,6 +47,11 @@ public static function cron(string $expression, object $message): self
return new self(CronExpressionTrigger::fromSpec($expression), $message);
}

public static function hashedCron(string $expression, string $context, object $message): self
{
return new self(new HashedCronExpressionTrigger($expression, $context), $message);
}

public static function trigger(TriggerInterface $trigger, object $message): self
{
return new self($trigger, $message);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?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 Symfony\Component\Scheduler\Trigger\HashedCronExpressionTrigger;

final class HashedCronExpressionTriggerTest extends TestCase
{
/**
* @dataProvider hashedExpressionProvider
*/
public function testExpressionParsing(string $input, string $expected)
{
$expressionA = new HashedCronExpressionTrigger($input, 'my task');
$expressionB = new HashedCronExpressionTrigger($input, 'my task');
$expressionC = new HashedCronExpressionTrigger($input, 'another task');

$this->assertSame($expected, $expressionA->getExpression());
$this->assertSame($expressionB->getExpression(), $expressionA->getExpression());
$this->assertNotSame($expressionC->getExpression(), $expressionA->getExpression());
}

public static function hashedExpressionProvider(): array
{
return [
['# * * * *', '56 * * * *'],
['# # * * *', '56 20 * * *'],
['# # # * *', '56 20 1 * *'],
['# # # # *', '56 20 1 9 *'],
['# # # # #', '56 20 1 9 0'],
['# # 1,15 1-11 *', '56 20 1,15 1-11 *'],
['# # 1,15 * *', '56 20 1,15 * *'],
['#hourly', '56 * * * *'],
['#daily', '56 20 * * *'],
['#weekly', '56 20 * * 0'],
['#weekly@midnight', '56 2 * * 0'],
['#monthly', '56 20 1 * *'],
['#monthly@midnight', '56 2 1 * *'],
['#yearly', '56 20 1 9 *'],
['#yearly@midnight', '56 2 1 9 *'],
['#annually', '56 20 1 9 *'],
['#annually@midnight', '56 2 1 9 *'],
['#midnight', '56 2 * * *'],
['#(1-15) * * * *', '12 * * * *'],
['#(1-15) * * * #(3-5)', '12 * * * 5'],
['#(1-15) * # * #(3-5)', '12 * 1 * 5'],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?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\Trigger;

use Cron\CronExpression;
use Symfony\Component\Scheduler\Exception\InvalidArgumentException;

/**
* Use "hashed" cron expressions to describe a periodical trigger.
*
* @author Kevin Bond <kevinbond@gmail.com>
*
* @experimental
*/
final class HashedCronExpressionTrigger implements TriggerInterface
{
private const 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 RANGES = [
[0, 59],
[0, 23],
[1, 28],
[1, 12],
[0, 6],
];

private CronExpressionTrigger $trigger;
private CronExpression $expression;

public function __construct(string $expression, string $context)
{
$this->trigger = new CronExpressionTrigger(
$this->expression = new CronExpression(self::parse($expression, $context))
);
}

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

public function getExpression(): string
{
return $this->expression;
}

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

if (count($parts) !== 5) {
throw new InvalidArgumentException(sprintf('"%s" is an invalid cron expression.', $expression));
}

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

return implode(' ', $parts);
}

private static function hashField(int $start, int $end, string $context): string
{
$possibleValues = range($start, $end);

return $possibleValues[(int) fmod(hexdec(substr(md5($context), 0, 10)), count($possibleValues))];
}
}

0 comments on commit 76316a7

Please sign in to comment.