Skip to content

Anchor an interval without a from date to a fixed point in time - #2

Merged
KalimeroMK merged 9 commits into
masterfrom
fix-periodic-trigger-anchor
Oct 18, 2026
Merged

KalimeroMK merged 9 commits into
masterfrom
fix-periodic-trigger-anchor

Conversation

@samdark

@samdark samdark commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Problem

RecurringTask::every() never runs under schedule:run when the interval is longer than the time between the cron invocations. every('10 minutes') with the documented * * * * * php yii schedule:run entry gives no runs at all.

A PeriodicTrigger without a from date computed the next run as lastRun + interval. A process started anew for every tick has no lastRun of 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.php in the repository root and run php repro.php. It uses the test doubles from tests/Support.

repro.php
<?php

require 'vendor/autoload.php';

use Yiisoft\Scheduler\Handler\CallableTaskHandler;
use Yiisoft\Scheduler\RecurringTask;
use Yiisoft\Scheduler\Schedule;
use Yiisoft\Scheduler\Scheduler;
use Yiisoft\Scheduler\Tests\Support\InMemoryCache;
use Yiisoft\Scheduler\Tests\Support\TestClock;

$cache = new InMemoryCache();
$clock = new TestClock();
$ran = 0;
$task = RecurringTask::every('10 minutes', static function () use (&$ran): void {
    ++$ran;
});

// What "* * * * * php yii schedule:run" does: a new process, and so a new scheduler, every minute.
for ($minute = 0; $minute < 180; ++$minute) {
    $schedule = (new Schedule())->stateful($cache)->task($task);
    (new Scheduler([$schedule], new CallableTaskHandler(), $clock))->tick();
    $clock->advance('+1 minute');
}

echo "runs in 3 hours: $ran\n";

On master:

runs in 3 hours: 0

With this change:

runs in 3 hours: 17

Fix

Without a from date 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

  • Why an anchor and not more state. The alternative is to remember when each task last ran. The checkpoint holds one position for the whole schedule, so that means a new checkpoint format, and the runs would still be counted from whenever the schedule was first evaluated: two servers, or the same server after its cache was cleared, would disagree on when "every 10 minutes" is. A fixed anchor needs no state and gives the same answer in every process.
  • Why not stop moving the checkpoint on idle ticks. That is what makes missed runs detectable after downtime; leaving it behind would make every idle period look like downtime.
  • Why 2001-01-01 and not the Unix epoch. It is a Monday and the first day of a year, so every('1 week') runs on Mondays and every('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 in Asia/Kathmandu.
  • Why time-only intervals are counted in seconds. Anchoring sends every string interval through the code that multiplies a 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, so every('1 day') stays at local midnight.
  • Why relative formats are refused. 1 weekday or first monday of next month cannot 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) and every('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.
  • In the few timezones whose offset has changed by a part of an hour since 2001 or does so with the season (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

  • A daemon runs an interval task first at the next multiple of the interval, not one full interval after it was started.
  • schedule:list now shows the real next run of such a task instead of "now + interval".
  • The run that lands exactly on until is still returned, as it already was for an anchored trigger.
  • An interval of hours, minutes and whole seconds only is counted on the timestamp, whether given as seconds or as a DateInterval. Added by the wall clock, as an anchored DateInterval was 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, so every('1 day') stays at local midnight.
  • A DateInterval made from a relative format such as 1 weekday or first monday of next month is now refused when the trigger is created, because its multiples cannot be computed. Use a cron expression for those.
  • A sub-second interval counted from the default anchor no longer overflows.
  • A run counted in seconds keeps the fraction of a second of its anchor. With from at 00:00:00.5 and 10 seconds, master gives .5, 10.0, 20.0 for an integer interval; it is now .5, 10.5, 20.5.

Tests

  • A cron-style test in ConfigTest that runs every('10 minutes') with a new scheduler every minute for an hour and expects 6 runs. It fails on master with 0.
  • Trigger tests for the default anchor, with both a seconds and a DateInterval interval.
  • Trigger tests for the autumn clock change (no run skipped), a daily interval across a clock change, weeks starting on Mondays, and seconds agreeing with the same DateInterval in a timezone whose offset has changed since 1970.
  • Trigger tests for the fraction of a second of the anchor and for the default timezone being read when the trigger is asked.
  • phpunit.xml.dist pins date.timezone to UTC, the same line Read a cron expression in the default timezone unless one is given #3 adds: the expectations assume it.
  • Data-provider tests for relative formats being refused and plain ones accepted, and a test for a sub-second interval.

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

samdark and others added 2 commits October 12, 2026 00:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 11, 2026 21:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/Trigger/PeriodicTrigger.php Resolved
samdark and others added 4 commits October 12, 2026 01:15
…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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds only once #3 is in: on this branch CronExpressionTrigger still reads the expression in the timezone of $lastRun. Merging #3 first, or dropping the comparison, keeps it true.

|| 0 !== $interval->m
|| 0 !== $interval->d
|| 1 === $interval->invert
|| 0 !== (int) round($interval->f * 1_000_000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@KalimeroMK
KalimeroMK merged commit 70a615c into master Oct 18, 2026
25 checks passed
@KalimeroMK
KalimeroMK deleted the fix-periodic-trigger-anchor branch October 18, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants