diff --git a/CHANGELOG.md b/CHANGELOG.md index c5823a4..97ac6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 067111c..5401bc0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Trigger/CronExpressionTrigger.php b/src/Trigger/CronExpressionTrigger.php index f8e897b..f77c6ac 100644 --- a/src/Trigger/CronExpressionTrigger.php +++ b/src/Trigger/CronExpressionTrigger.php @@ -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; @@ -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( @@ -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) { + $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(); + } } diff --git a/src/Trigger/TriggerInterface.php b/src/Trigger/TriggerInterface.php index c474288..91b191d 100644 --- a/src/Trigger/TriggerInterface.php +++ b/src/Trigger/TriggerInterface.php @@ -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 { diff --git a/tests/Trigger/CronExpressionTriggerTest.php b/tests/Trigger/CronExpressionTriggerTest.php index ee0dd35..96c9200 100644 --- a/tests/Trigger/CronExpressionTriggerTest.php +++ b/tests/Trigger/CronExpressionTriggerTest.php @@ -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; @@ -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 + */ + 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')); + } + } }