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 27, 2023
1 parent c375406 commit aaccd55
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/Symfony/Component/Scheduler/RecurringMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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(CronExpressionTrigger::fromHash($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,65 @@
<?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\CronExpressionTrigger;

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

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

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'],
];
}

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

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

/**
Expand All @@ -23,6 +24,27 @@
*/
final class CronExpressionTrigger implements TriggerInterface, \Stringable
{
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 @@ -42,8 +64,42 @@ public static function fromSpec(string $expression = '* * * * *'): self
return new self(new CronExpression($expression));
}

public static function fromHash(string $expression, string $context): self
{
return self::fromSpec(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;
}

foreach ($parts as $position => $part) {
if (preg_match('#^\#(\((\d+)-(\d+)\))?$#', $part, $matches)) {
$parts[$position] = self::hashField(
(int) ($matches[2] ?? self::HASH_RANGES[$position][0]),
(int) ($matches[3] ?? self::HASH_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))];
}
}
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 (5 !== \count($parts)) {
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 aaccd55

Please sign in to comment.