8000 [Scheduler] add "hashed" cron expression support · symfony/symfony@3789a43 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3789a43

Browse files
committed
[Scheduler] add "hashed" cron expression support
1 parent c375406 commit 3789a43

File tree

4 files changed

+220
-0
lines changed

4 files changed

+220
-0
lines changed

src/Symfony/Component/Scheduler/RecurringMessage.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ public static function cron(string $expression, object $message): self
4646
return new self(CronExpressionTrigger::fromSpec($expression), $message);
4747
}
4848

49+
public static function hashedCron(string $expression, string $context, object $message): self
50+
{
51+
return new self(CronExpressionTrigger::fromHash($expression, $context), $message);
52+
}
53+
4954
public static function trigger(TriggerInterface $trigger, object $message): self
5055
{
5156
return new self($trigger, $message);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Scheduler\Tests\Trigger;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;
16+
17+
final class CronExpressionTriggerTest extends TestCase
18+
{
19+
/**
20+
* @dataProvider hashedExpressionProvider
21+
*/
22+
public 8000 function testHashedExpressionParsing(string $input, string $expected)
23+
{
24+
$triggerA = CronExpressionTrigger::fromHash($input, 'my task');
25+
$triggerB = CronExpressionTrigger::fromHash($input, 'my task');
26+
$triggerC = CronExpressionTrigger::fromHash($input, 'another task');
27+
28+
$this->assertSame('cron: '.$expected, (string) $triggerA);
29+
$this->assertSame((string) $triggerB, (string) $triggerA);
30+
$this->assertNotSame((string) $triggerC, (string) $triggerA);
31+
}
32+
33+
public static function hashedExpressionProvider(): array
34+
{
35+
return [
36+
['# * * * *', '56 * * * *'],
37+
['# # * * *', '56 20 * * *'],
38+
['# # # * *', '56 20 1 * *'],
39+
['# # # # *', '56 20 1 9 *'],
40+
['# # # # #', '56 20 1 9 0'],
41+
['# # 1,15 1-11 *', '56 20 1,15 1-11 *'],
42+
['# # 1,15 * *', '56 20 1,15 * *'],
43+
['#hourly', '56 * * * *'],
44+
['#daily', '56 20 * * *'],
45+
['#weekly', '56 20 * * 0'],
46+
['#weekly@midnight', '56 2 * * 0'],
47+
['#monthly', '56 20 1 * *'],
48+
['#monthly@midnight', '56 2 1 * *'],
49+
['#yearly', '56 20 1 9 *'],
50+
['#yearly@midnight', '56 2 1 9 *'],
51+
['#annually', '56 20 1 9 *'],
52+
['#annually@midnight', '56 2 1 9 *'],
53+
['#midnight', '56 2 * * *'],
54+
['#(1-15) * * * *', '12 * * * *'],
55+
['#(1-15) * * * #(3-5)', '12 * * * 5'],
56+
['#(1-15) * # * #(3-5)', '12 * 1 * 5'],
57+
];
58+
}
59+
60+
public function testFromHashWithStandardExpression()
61+
{
62+
$this->assertSame('cron: 56 20 1 9 0', (string) CronExpressionTrigger::fromHash('56 20 1 9 0', 'some context'));
63+
$this->assertSame('cron: 0 0 * * *', (string) CronExpressionTrigger::fromHash('@daily', 'some context'));
64+
}
65+
}

src/Symfony/Component/Scheduler/Trigger/CronExpressionTrigger.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@
2323
*/
2424
final class CronExpressionTrigger implements TriggerInterface, \Stringable
2525
{
26+
private const HASH_ALIAS_MAP = [
27+
'#hourly' => '# * * * *',
28+
'#daily' => '# # * * *',
29+
'#weekly' => '# # * * #',
30+
'#weekly F438 @midnight' => '# #(0-2) * * #',
31+
'#monthly' => '# # # * *',
32+
'#monthly@midnight' => '# #(0-2) # * *',
33+
'#annually' => '# # # # *',
34+
'#annually@midnight' => '# #(0-2) # # *',
35+
'#yearly' => '# # # # *',
36+
'#yearly@midnight' => '# #(0-2) # # *',
37+
'#midnight' => '# #(0-2) * * *',
38+
];
39+
private const HASH_RANGES = [
40+
[0, 59],
41+
[0, 23],
42+
[1, 28],
43+
[1, 12],
44+
[0, 6],
45+
];
46+
2647
public function __construct(
2748
private readonly CronExpression $expression = new CronExpression('* * * * *'),
2849
) {
@@ -42,8 +63,42 @@ public static function fromSpec(string $expression = '* * * * *'): self
4263
return new self(new CronExpression($expression));
4364
}
4465

66+
public static function fromHash(string $expression, string $context): self
67+
{
68+
return self::fromSpec(self::parseHashed($expression, $context));
69+
}
70+
4571
public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable
4672
{
4773
return \DateTimeImmutable::createFromMutable($this->expression->getNextRunDate($run));
4874
}
75+
76+
private static function parseHashed(string $expression, string $context): string
77+
{
78+
$expression = self::HASH_ALIAS_MAP[$expression] ?? $expression;
79+
$parts = explode(' ', $expression);
80+
81+
if (5 !== \count($parts)) {
82+
return $expression;
83+
}
84+
85+
foreach ($parts as $position => $part) {
86+
if (preg_match('#^\#(\((\d+)-(\d+)\))?$#', $part, $matches)) {
87+
$parts[$position] = self::hashField(
88+
(int) ($matches[2] ?? self::HASH_RANGES[$position][0]),
89+
(int) ($matches[3] ?? self::HASH_RANGES[$position][1]),
90+
$context
91+
);
92+
}
93+
}
94+
95+
return implode(' ', $parts);
96+
}
97+
98+
private static function hashField(int $start, int $end, string $context): string
99+
{
100+
$possibleValues = range($start, $end);
101+
102+
return $possibleValues[(int) fmod(hexdec(substr(md5($context), 0, 10)), \count($possibleValues))];
103+
}
49104
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Scheduler\Trigger;
13+
14+
use Cron\CronExpression;
15+
use Symfony\Component\Scheduler\Exception\InvalidArgumentException;
16+
17+
/**
18+
* Use "hashed" cron expressions to describe a periodical trigger.
19+
*
20+
* @author Kevin Bond <kevinbond@gmail.com>
21+
*
22+
* @experimental
23+
*/
24+
final class HashedCronExpressionTrigger implements TriggerInterface
25+
{
26+
private const ALIAS_MAP = [
27+
'#hourly' => '# * * * *',
28+
'#daily' => '# # * * *',
29+
'#weekly' => '# # * * #',
30+
'#weekly@midnight' => '# #(0-2) * * #',
31+
'#monthly' => '# # # * *',
32+
'#monthly@midnight' => '# #(0-2) # * *',
33+
'#annually' => '# # # # *',
34+
'#annually@midnight' => '# #(0-2) # # *',
35+
'#yearly' => '# # # # *',
36+
'#yearly@midnight' => '# #(0-2) # # *',
37+
'#midnight' => '# #(0-2) * * *',
38+
];
39+
private const RANGES = [
40+
[0, 59],
41+
[0, 23],
42+
[1, 28],
43+
[1, 12],
44+
[0, 6],
45+
];
46+
47+
private CronExpressionTrigger $trigger;
48+
private CronExpression $expression;
49+
50+
public function __construct(string $expression, string $context)
51+
{
52+
$this->trigger = new CronExpressionTrigger(
53+
$this->expression = new CronExpression(self::parse($expression, $context))
54+
);
55+
}
56+
57+
public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable
58+
{
59+
return $this->trigger->getNextRunDate($run);
60+
}
61+
62+
public function getExpression(): string
63+
{
64+
return $this->expression;
65+
}
66+
67+
private static function parse(string $expression, string $context): string
68+
{
69+
$expression = self::ALIAS_MAP[$expression] ?? $expression;
70+
$parts = explode(' ', $expression);
71+
72+
if (5 !== \count($parts)) {
73+
throw new InvalidArgumentException(sprintf('"%s" is an invalid cron expression.', $expression));
74+
}
75+
76+
foreach ($parts as $position => $part) {
77+
if (preg_match('#^\#(\((\d+)-(\d+)\))?$#', $part, $matches)) {
78+
$parts[$position] = self::hashField(
79+
(int) ($matches[2] ?? self::RANGES[$position][0]),
80+
(int) ($matches[3] ?? self::RANGES[$position][1]),
81+
$context
82+
);
83+
}
84+
}
85+
86+
return implode(' ', $parts);
87+
}
88+
89+
private static function hashField(int $start, int $end, string $context): string
90+
{
91+
$possibleValues = range($start, $end);
92+
93+
return $possibleValues[(int) fmod(hexdec(substr(md5($context), 0, 10)), \count($possibleValues))];
94+
}
95+
}

0 commit comments

Comments
 (0)
0