Repository navigation
Anchor an interval without a from date to a fixed point in time - #2
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new default-anchor path breaks supported relative DateInterval expressions such as 1 weekday.
1 open finding
What changed in this PR
Anchors unbounded periodic triggers to a fixed epoch so interval tasks work across separate scheduler invocations.
Changes:
- Adds fixed-anchor periodic scheduling.
- Adds regression and trigger tests.
- Updates documentation and modernizes minor syntax.
| File | Description |
|---|---|
src/Trigger/PeriodicTrigger.php |
Implements the default fixed anchor. |
src/Command/ListCommand.php |
Simplifies exhausted-run formatting. |
tests/Trigger/PeriodicTriggerTest.php |
Tests fixed-anchor behavior. |
tests/ConfigTest.php |
Tests cron-style interval execution. |
tests/SchedulerTest.php |
Modernizes cleanup callbacks. |
README.md |
Documents interval anchoring. |
CHANGELOG.md |
Records the fix. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…nd one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interval of hours, minutes and seconds was added to the anchor by the wall clock, which skipped the hour repeated when the clocks go back and every run due in it. It is now counted in seconds, like an integer one. The default anchor is January 1, 2001 instead of the Unix epoch: a Monday, so weeks start there, and recent enough for a timezone to be at its present offset, which made seconds and a date interval disagree. The README paragraph moves down, off the line another pull request edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…conds Setting a timestamp drops the microseconds, so an interval counted on the timestamp from an anchor such as "now" ran its second run short of a full interval and every later one off the anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mezone The default timezone was taken when the trigger was created, which for a task declared in a configuration file is before the application may have set it, and not when a cron expression reads it. The tests assume UTC; pin it as the pull request for cron expressions does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| * they have today, which many were not in 1970; where the offset has changed since, or | ||
| * changes with the season, an interval counted in seconds is off the local hour by that | ||
| * much. It is read in the default timezone of the moment the trigger is asked, like the | ||
| * cron expression of a CronExpressionTrigger. |
| || 0 !== $interval->m | ||
| || 0 !== $interval->d | ||
| || 1 === $interval->invert | ||
| || 0 !== (int) round($interval->f * 1_000_000) |
There was a problem hiding this comment.
nit: this part of the guard is untested: removing it keeps the suite green, and every('1 second 500 milliseconds') then runs at 01.0 / 02.0 / 03.0 instead of 01.5 / 03.0 / 04.5. The 500 microseconds cases return on s = 0 before reaching it.
| * and the end of a month, a weekday and a weekend, so a relative format shows itself on | ||
| * at least one of them. | ||
| */ | ||
| private const SCALABILITY_PROBES = ['2001-01-17 12:34:56 UTC', '2001-03-31 00:00:00 UTC']; |
There was a problem hiding this comment.
nit: every format in relativeFormats() is caught by the first probe, so either probe can be removed with the suite green. '0 weekdays' is rejected only with the second one; adding it would pin the claim.
| } | ||
| } else { | ||
| $next = $this->firstMultipleAfter($this->from, $lastRun); | ||
| $next = $this->firstMultipleAfter($from, $lastRun); |
There was a problem hiding this comment.
nit: this path returns the date in the anchor's timezone, the timestamp path in $lastRun's. Default Moscow, $lastRun in UTC: PT1H gives +00:00, P1D gives +03:00. Same instant, but triggeredAt and schedule:list change timezone with the interval type; ->setTimezone($lastRun->getTimezone()) here would align them.
| * never leads to; it is refused here, where the task is declared, rather than left to | ||
| * run at the wrong time or not at all. | ||
| */ | ||
| private function assertScalable(): void |
There was a problem hiding this comment.
nit: '-1 day' and '0 seconds' pass this and fail only on the first getNextRunDate(), while an int <= 0 is refused in the constructor. Checking multiple($probe, 1) > $probe here would refuse them up front.

Problem
RecurringTask::every()never runs underschedule:runwhen the interval is longer than the time between the cron invocations.every('10 minutes')with the documented* * * * * php yii schedule:runentry gives no runs at all.A
PeriodicTriggerwithout afromdate computed the next run aslastRun + interval. A process started anew for every tick has nolastRunof its own, so it takes the checkpoint; and every idle tick moves the checkpoint to "now". The next run was therefore always one interval away from the current invocation.How to reproduce
Save as
repro.phpin the repository root and runphp repro.php. It uses the test doubles fromtests/Support.repro.php
On
master:With this change:
Fix
Without a
fromdate the trigger is anchored to a fixed point: midnight of January 1, 2001 in the default timezone. The run dates are then a pure function of the date asked about:every('10 minutes')runs at :00, :10, :20 and so on.Why this way
every('1 week')runs on Mondays andevery('1 month')on the first. It is also recent enough for most timezones to be at the offset they have today; with the Unix epoch,every(3600)ran at :15 inAsia/Kathmandu.DateInterval, which adds hours and minutes by the wall clock.every('30 minutes')then skipped the hour repeated when the clocks go back. Counted on the timestamp, as an integer interval already was, nothing is skipped. Intervals with days, months or years still follow the calendar, soevery('1 day')stays at local midnight.1 weekdayorfirst monday of next monthcannot be multiplied, so an anchored trigger would run them at the wrong time or never. Failing where the task is declared is better than that.Limits
An interval counted in seconds cannot stay on the local hour where the offset moves:
every(86400)andevery('24 hours')are 24 hours apart, so in a timezone with summer time they run at 00:00 in winter and 01:00 in summer.every('1 day')follows the calendar and stays at midnight.Asia/Colombo,Australia/Lord_Howe,Pacific/Norfolk), an hourly interval runs at :30 for all or part of the year.The default anchor is read in the default timezone of the moment the trigger is asked, not of the moment it was created, as a cron expression is after #3.
Behaviour changes
schedule:listnow shows the real next run of such a task instead of "now + interval".untilis still returned, as it already was for an anchored trigger.DateInterval. Added by the wall clock, as an anchoredDateIntervalwas before,every('30 minutes')skipped the hour repeated when the clocks go back. An interval with days, months or years in it still follows the calendar, soevery('1 day')stays at local midnight.DateIntervalmade from a relative format such as1 weekdayorfirst monday of next monthis now refused when the trigger is created, because its multiples cannot be computed. Use a cron expression for those.fromat 00:00:00.5 and 10 seconds,mastergives .5, 10.0, 20.0 for an integer interval; it is now .5, 10.5, 20.5.Tests
ConfigTestthat runsevery('10 minutes')with a new scheduler every minute for an hour and expects 6 runs. It fails onmasterwith 0.DateIntervalinterval.DateIntervalin a timezone whose offset has changed since 1970.phpunit.xml.distpinsdate.timezoneto UTC, the same line Read a cron expression in the default timezone unless one is given #3 adds: the expectations assume it.The README paragraph about this sits further down than the example it refers to, so that it does not touch the line #11 edits.
🤖 Generated with Claude Code