Skip to content

fix: prevent new scheduled jobs from firing immediately on deploy - #10

Merged
balazscsaba2006 merged 1 commit into
mainfrom
fix/schedule-null-last-triggered-at
Mar 30, 2026
Merged

balazscsaba2006 merged 1 commit into
mainfrom
fix/schedule-null-last-triggered-at

Conversation

@balazscsaba2006

@balazscsaba2006 balazscsaba2006 commented Mar 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Schedule::isDue() treated null lastTriggeredAt (new job, no cron_dates entry) as a missed run because PHP evaluates null < DateTime to true
  • This caused every new scheduled job to fire immediately on deploy, regardless of its cron expression
  • New jobs now correctly wait for their first scheduled time

Root cause

// null < $this->cronExpression->getPreviousRunDate() -> always true in PHP
if ($lastTriggeredAt < $this->cronExpression->getPreviousRunDate()) {
    return true;
}

Impact

This bug was latent since the initial commit (June 2022). It went unnoticed because:

  • Daily/hourly jobs: firing on deploy is usually harmless (they would run that day anyway)
  • PostDeployment jobs: have their own shouldRun() that intentionally fires on null
  • Weekly jobs: first affected when added day-specific weekly job -- deploying on Friday caused Tuesday/Wednesday jobs to fire immediately

Fix

Added $lastTriggeredAt !== null guard to both comparison branches. When lastTriggeredAt is null (new job), isDue() now falls through to CronExpression::isDue(now) -- which correctly checks if the current time matches the cron schedule.

Test plan

  • All 37 tests passing
  • PHPStan clean
  • Release as patch version (2.3.1)

Schedule::isDue() treated null lastTriggeredAt as a missed run because
PHP evaluates null < DateTime to true. New jobs now wait for their
first scheduled time instead of firing on deploy.
@balazscsaba2006
balazscsaba2006 merged commit 7fefec2 into main Mar 30, 2026
8 checks passed

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.

Pull request overview

Fixes Schedule::isDue() so that brand-new scheduled jobs (with null lastTriggeredAt) don’t get treated as “missed” and run immediately on deploy; instead they wait until they’re actually due per the cron expression.

Changes:

  • Add null guards in Schedule::isDue() before comparing lastTriggeredAt to computed dates.
  • Update and expand test coverage to assert new jobs don’t fire immediately when not due, and still do fire when due.
  • Adjust ScheduleBasedCronJob behavior tests to reflect the corrected scheduling semantics.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/Cron/Schedule.php Prevents null lastTriggeredAt from triggering “missed run” logic; falls back to cron isDue(now) evaluation.
tests/Cron/ScheduleTest.php Moves/extends assertions to cover “new job + not due” and “new job + due” behavior.
tests/Cron/CronJobImplementationTest.php Updates missed-run test to be due “now” and adds coverage ensuring new jobs don’t run when not currently due.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +91 to 101
public function testNewJobShouldFireImmediatelyWhenDue(): void
{
$now = CarbonImmutable::now();

// A job that IS due right now should still fire even with null lastTriggeredAt
$s = Schedule::everyMinute();
$this->assertTrue($s->isDue(null));

$s = Schedule::everyDay($now->hour, $now->minute);
$this->assertTrue($s->isDue(null));
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

These assertions depend on real wall-clock time while Schedule::isDue() calls Carbon::now()/CarbonImmutable::now() internally. If the test runs across a minute boundary, everyDay($now->hour, $now->minute) (and similar time-matching assertions in this file) can become non-due and make the test flaky. Consider freezing time with CarbonImmutable::setTestNow(...) (and resetting afterwards) around tests that expect exact minute matches.

Copilot uses AI. Check for mistakes.
Comment on lines 32 to +47
public function testItShouldRunIfPreviousRunDateMissed(): void
{
$c = new class() extends ScheduleBasedCronJob {
public function getSchedule(): Schedule
{
$now = CarbonImmutable::now();
$tenMinutesLater = $now->addMinutes(10);
return Schedule::everyHour(CarbonImmutable::now()->minute);
}

public function run(?DateTimeInterface $lastStartedAt): void
{
}
};

// Job is due now but last triggered 2 hours ago — catch-up should fire
$this->assertTrue($c->shouldRun(CarbonImmutable::now()->subHours(2)));
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

getSchedule() uses CarbonImmutable::now()->minute, but Schedule::isDue() also fetches its own now internally. If execution crosses a minute boundary between those calls, this test can become flaky (schedule minute and evaluation minute differ). Freezing time via CarbonImmutable::setTestNow(...) in the test (and resetting after) would make this deterministic.

Copilot uses AI. Check for mistakes.
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.

4 participants