Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 1.0.0 under development

- New #36: Answer a bounded part of a cron sequence as well: `CronExpressionTrigger` implements `TriggerRangeInterface`, walking the expression itself, so a batch costs its own length rather than the span it is taken from. An expression names a minute, so a date already past the start of one is answered with the run after it rather than the one just missed (@KalimeroMK)
- New #36: Add `TriggerRangeInterface`, so a trigger can be asked for a bounded part of its sequence instead of one run date at a time: `getNextRunDateAtOrAfter()` answers with the run at the date asked about, and `getRunDates()` with the runs between two dates, at most as many as asked for. `PeriodicTrigger` implements it by counting intervals from its anchor, so a batch costs its own length and one search. The dates are the ones the trigger gives one at a time, in the same order (@KalimeroMK)
- Bug #30: Fix two tasks differing only in the timezone their cron expression is read in, in the date their interval is counted from or stops at, or in the payload of the queue message they push, sharing one generated identifier, so that a schedule rejected the second as a duplicate and a checkpoint held the runs of both under one record; a trigger now describes itself with its timezone and its bounds, a message task is told apart by its payload as well as its type, and a cron task takes its identifier from that description rather than from the expression alone (@KalimeroMK)
- Enh #17: Keep a checkpoint record per task instead of one record holding every run emitted at a due time, so that a stored value stays the same size however many tasks share that time and a run costs a single write rather than a write of everything emitted before it (@KalimeroMK)
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,35 @@ $schedule = (new Schedule())
->task(...);
```

## Asking a trigger for more than the next run

A trigger answers one question: given a run, when is the next one? That is all a schedule needs
while it keeps up. It is not all it needs after an outage: a task due every minute has some
forty thousand runs behind it after thirty days, and walking them one at a time is work the
schedule does before it can run anything else.

`TriggerRangeInterface` adds two questions for that case, and both built-in triggers answer
them:

```php
$trigger = new PeriodicTrigger(600, new DateTimeImmutable('2026-01-01 00:00:00'));

// The first run at or after a date, rather than strictly after it.
$trigger->getNextRunDateAtOrAfter(new DateTimeImmutable('2026-01-01 00:10:00'));

// A hundred runs at most, from the backlog between two dates.
$batch = $trigger->getRunDates($lastHandled, $now, 100);
```

Both bounds of a range are exclusive, so a batch continued from its own last date neither skips
a run nor repeats one, and both questions take a `$phase`: a run already accounted for, never
answered with again. The dates are the ones the trigger gives one at a time, in the same order —
a range is a different question about the same sequence, not a different sequence.

A custom trigger implements `TriggerInterface` alone and is asked one date at a time, as before.
Implement `TriggerRangeInterface` as well when the sequence can be enumerated more cheaply than
by walking it.

## Task identifiers

Every task gets an identifier generated from the description of its trigger and from what it
Expand Down
71 changes: 63 additions & 8 deletions src/Trigger/CronExpressionTrigger.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/**
* A trigger backed by a cron expression, evaluated by dragonmantank/cron-expression.
*/
final class CronExpressionTrigger implements TriggerInterface
final class CronExpressionTrigger implements TriggerRangeInterface
{
private readonly CronExpression $cron;
private readonly ?DateTimeZone $timezone;
Expand Down Expand Up @@ -48,13 +48,9 @@ public function __toString(): string

public function getNextRunDate(DateTimeImmutable $lastRun): ?DateTimeImmutable
{
// Not the timezone of the given date: one restored from a checkpoint is in UTC whatever
// timezone it was saved in, and the expression would silently change its meaning with it.
$timezone = $this->timezone?->getName() ?? date_default_timezone_get();

$next = $this->cron->getNextRunDate($lastRun, 0, false, $timezone);

$next = DateTimeImmutable::createFromInterface($next);
$next = DateTimeImmutable::createFromInterface(
$this->cron->getNextRunDate($lastRun, 0, false, $this->timezone()),
);

if ($next <= $lastRun) {
throw new LogicException(
Expand All @@ -64,4 +60,63 @@ public function getNextRunDate(DateTimeImmutable $lastRun): ?DateTimeImmutable

return $next;
}

public function getNextRunDateAtOrAfter(DateTimeImmutable $date, ?DateTimeImmutable $phase = null): ?DateTimeImmutable
{
if (null !== $phase && $phase >= $date) {
return $this->getNextRunDate($phase);
}

$next = DateTimeImmutable::createFromInterface(
$this->cron->getNextRunDate($date, 0, true, $this->timezone()),
);

// An expression names a minute, so a date past the start of one is answered with that
// minute, which is behind the date asked about. The run at that minute has been and
// gone; the answer is the one after the date itself.
if ($next < $date) {
return $this->getNextRunDate($date);
}

return $next;
}

public function getRunDates(
DateTimeImmutable $after,
DateTimeImmutable $before,
int $limit,
?DateTimeImmutable $phase = null,
): array {
if ($limit <= 0) {
throw new LogicException(sprintf('The limit must be greater than zero, "%d" given.', $limit));
}

$cursor = null !== $phase && $phase > $after ? $phase : $after;

$dates = [];

// The library walks the expression itself, so the cost is the batch and not the span it
// is taken from: a backlog of a month is as cheap to open as a backlog of an hour.
foreach ($this->cron->getMultipleRunDates($limit, $cursor, false, false, $this->timezone()) as $date) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
$date = DateTimeImmutable::createFromInterface($date);

if ($date >= $before) {
break;
}

$dates[] = $date;
}

return $dates;
}

/**
* The timezone the expression is read in. Not the timezone of the date asked about: one
* restored from a checkpoint is in UTC whatever timezone it was saved in, and the expression
* would silently change its meaning with it.
*/
private function timezone(): string
{
return $this->timezone?->getName() ?? date_default_timezone_get();
}
}
4 changes: 4 additions & 0 deletions src/Trigger/TriggerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
*
* Implementations must be stateless and must return a date strictly after the given one;
* returning null means the trigger is exhausted and won't be asked again.
*
* This is the whole contract: a trigger answering only this is asked one date at a time,
* however far behind the schedule is. One whose sequence can be enumerated more cheaply than by
* walking it answers {@see TriggerRangeInterface} as well, and is asked for a batch at a time.
*/
interface TriggerInterface extends Stringable
{
Expand Down
201 changes: 200 additions & 1 deletion tests/Trigger/CronExpressionTriggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
namespace Yiisoft\Scheduler\Tests\Trigger;

use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Yiisoft\Scheduler\Exception\LogicException;
use Yiisoft\Scheduler\Trigger\CronExpressionTrigger;
use DateTimeZone;

use function count;
use function sprintf;
use function date_default_timezone_get;
use function date_default_timezone_set;

Expand Down Expand Up @@ -67,4 +71,199 @@ public function testToString(): void
{
$this->assertSame('cron(0 5 * * *)', (string) new CronExpressionTrigger('0 5 * * *'));
}

/**
* The property a range rests on: asked for a part of the sequence, the trigger answers with
* the dates it would have answered with one at a time.
*/
#[DataProvider('ranges')]
public function testARangeHoldsExactlyTheDatesTheTriggerWouldGiveOneAtATime(
CronExpressionTrigger $trigger,
DateTimeImmutable $after,
DateTimeImmutable $before,
): void {
$oneAtATime = [];
$cursor = $after;

while (true) {
$next = $trigger->getNextRunDate($cursor);

if (null === $next || $next >= $before) {
break;
}

$oneAtATime[] = $next;
$cursor = $next;
}

$this->assertNotSame([], $oneAtATime);
$this->assertEquals($oneAtATime, $trigger->getRunDates($after, $before, 400));
}

/**
* @return iterable<string, array{CronExpressionTrigger, DateTimeImmutable, DateTimeImmutable}>
*/
public static function ranges(): iterable
{
$utc = new DateTimeZone('UTC');
$skopje = new DateTimeZone('Europe/Skopje');

yield 'every hour' => [
new CronExpressionTrigger('0 * * * *'),
new DateTimeImmutable('2026-03-01 00:00:00', $utc),
new DateTimeImmutable('2026-03-01 08:00:00', $utc),
];

yield 'an hour that the spring change takes away' => [
new CronExpressionTrigger('0 2 * * *', 'Europe/Skopje'),
new DateTimeImmutable('2026-03-27 00:00:00', $skopje),
new DateTimeImmutable('2026-04-01 00:00:00', $skopje),
];

yield 'a day that only leap years have' => [
new CronExpressionTrigger('0 0 29 2 *'),
new DateTimeImmutable('2024-01-01 00:00:00', $utc),
new DateTimeImmutable('2033-01-01 00:00:00', $utc),
];
}

public function testABatchContinuedFromItsLastDateCoversTheRangeExactlyOnce(): void
{
$trigger = new CronExpressionTrigger('0 * * * *');
$after = new DateTimeImmutable('2026-01-01 00:00:00+00:00');
$before = new DateTimeImmutable('2026-01-01 06:00:00+00:00');

$batched = [];
$cursor = $after;

while ([] !== $batch = $trigger->getRunDates($cursor, $before, 2)) {
$batched = [...$batched, ...$batch];
$cursor = $batch[count($batch) - 1];
}

$this->assertEquals($trigger->getRunDates($after, $before, 100), $batched);
}

public function testTheMinuteAskedAboutIsItselfAnAnswer(): void
{
$trigger = new CronExpressionTrigger('0 * * * *');
$onTheHour = new DateTimeImmutable('2026-01-01 05:00:00+00:00');

$this->assertEquals($onTheHour, $trigger->getNextRunDateAtOrAfter($onTheHour));
}

public function testAMinuteAlreadyUnderWayIsBehindTheDateAskedAbout(): void
{
// An expression names a minute, and the library answers with the start of it, so half a
// minute past the run is past it: the answer is the next one, not the one just missed.
$trigger = new CronExpressionTrigger('0 * * * *');

$this->assertSame(
'2026-01-01 06:00:00',
$trigger
->getNextRunDateAtOrAfter(new DateTimeImmutable('2026-01-01 05:00:30+00:00'))
?->format('Y-m-d H:i:s'),
);
}

public function testARangeNeverAnswersWithADateBeforeTheOneAskedAbout(): void
{
// Half an hour into a run is past it, and a change of the clock is where that is easiest
// to get wrong: these are the days the clock moves in zones that move it by half an hour
// and by a whole one.
foreach (['Europe/Skopje', 'Australia/Lord_Howe', 'America/St_Johns'] as $zone) {
$trigger = new CronExpressionTrigger('*/15 * * * *', $zone);
$timezone = new DateTimeZone($zone);

foreach (['2026-03-29', '2026-04-05', '2026-10-04', '2026-10-25'] as $day) {
for ($hour = 0; $hour < 24; ++$hour) {
$date = new DateTimeImmutable(sprintf('%s %02d:30:00', $day, $hour), $timezone);
$next = $trigger->getNextRunDateAtOrAfter($date);

$this->assertNotNull($next);
$this->assertGreaterThanOrEqual($date, $next, sprintf('at %s', $date->format('c')));
}
}
}
}

public function testARunAlreadyAccountedForIsNotGivenAgain(): void
{
$trigger = new CronExpressionTrigger('0 * * * *');
$onTheHour = new DateTimeImmutable('2026-01-01 05:00:00+00:00');

$this->assertSame(
'2026-01-01 06:00:00',
$trigger->getNextRunDateAtOrAfter($onTheHour, $onTheHour)?->format('Y-m-d H:i:s'),
);
}

public function testAPhaseCarriesARangePastTheRunsAlreadyAccountedFor(): void
{
$trigger = new CronExpressionTrigger('0 * * * *');

$dates = $trigger->getRunDates(
new DateTimeImmutable('2026-01-01 00:00:00+00:00'),
new DateTimeImmutable('2026-01-01 06:00:00+00:00'),
10,
new DateTimeImmutable('2026-01-01 03:00:00+00:00'),
);

$this->assertCount(2, $dates);
$this->assertSame('2026-01-01 04:00:00', $dates[0]->format('Y-m-d H:i:s'));
}

public function testARangeThatEndsBeforeItStartsIsEmpty(): void
{
$trigger = new CronExpressionTrigger('0 * * * *');

$this->assertSame([], $trigger->getRunDates(
new DateTimeImmutable('2026-01-02 00:00:00+00:00'),
new DateTimeImmutable('2026-01-01 00:00:00+00:00'),
5,
));
}

public function testARangeHoldsNoMoreDatesThanAskedFor(): void
{
$trigger = new CronExpressionTrigger('* * * * *');

$dates = $trigger->getRunDates(
new DateTimeImmutable('2026-01-01 00:00:00+00:00'),
new DateTimeImmutable('2026-01-02 00:00:00+00:00'),
3,
);

$this->assertCount(3, $dates);
$this->assertSame('2026-01-01 00:03:00', $dates[2]->format('Y-m-d H:i:s'));
}

public function testALimitOfNoneIsRejected(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('The limit must be greater than zero, "0" given.');

(new CronExpressionTrigger('* * * * *'))->getRunDates(
new DateTimeImmutable('2026-01-01 00:00:00+00:00'),
new DateTimeImmutable('2026-01-02 00:00:00+00:00'),
0,
);
}

public function testARangeIsReadInTheTimezoneOfTheExpressionAndNotOfTheDate(): void
{
$trigger = new CronExpressionTrigger('0 9 * * *', 'Europe/Skopje');

$dates = $trigger->getRunDates(
new DateTimeImmutable('2026-01-01 00:00:00+00:00'),
new DateTimeImmutable('2026-01-03 00:00:00+00:00'),
10,
);

$this->assertCount(2, $dates);

foreach ($dates as $date) {
$this->assertSame('09:00', $date->setTimezone(new DateTimeZone('Europe/Skopje'))->format('H:i'));
}
}
}
Loading