Skip to content

Commit

Permalink
feature #49792 [Scheduler] add "hashed" cron expression support (kbond)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 6.3 branch.

Discussion
----------

[Scheduler] add "hashed" cron expression support

| Q             | A
| ------------- | ---
| Branch?       | 6.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | n/a
| License       | MIT
| Doc PR        | todo

This is an interesting feature from zenstruck/schedule-bundle that I borrowed from Jenkins. It helps with the problem touched upon in [Fabien's talk at SFLive Paris 2023](https://speakerdeck.com/fabpot/s?slide=33).

From the [zenstruck/schedule-bundle docs](https://github.com/zenstruck/schedule-bundle/blob/1.x/doc/define-tasks.md#hashed-cron-expression) _(some of the issues discussed below don't apply to symfony/scheduler the same way)_:

> If you have many tasks scheduled at midnight (0 0 * * *) this could create a very long running schedule right at this time. Tasks scheduled at the same time are run synchronously. This may cause an issue if a task has a memory leak.
>
> This bundle extends the standard Cron expression syntax by adding a # (for hash) symbol. # is replaced with a random value for the field. The value is deterministic based on the task's description. This means that while the value is random, it is predictable and consistent. A task with the description my task and a defined frequency of # # * * * will have a calculated frequency of 56 20 * * * (every day at 8:56pm). Changing the task's description will change it's calculated frequency. If the task from the previous example's description is changed to another task, it's calculated frequency would change to 24 12 * * * (every day at 12:24pm).
>
> A hash range #(x-y) can also be used. For example, # #(0-7) * * * means daily, some time between midnight and 7am. Using the # without a range creates a range of any valid value for the field. # # # # # is short for #(0-59) #(0-23) #(1-28) #(1-12) #(0-6). Note the day of month range is 1-28, this is to account for February which has a minimum of 28 days.

### Usage

```php
// MyMessage MUST be stringable to use "hashed cron"
$schedule->add(RecurringMessage::cron('#midnight', new MyMessage()));
```

Commits
-------

6ff8c28 [Scheduler] add "hashed" cron expression support
  • Loading branch information
fabpot committed Apr 25, 2023
2 parents 55a38a8 + 6ff8c28 commit 1f8c592
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 1f8c592

Please sign in to comment.