Graceful Deploys for Long-Running Scheduled Commands: hasBeenInterruptedSince() in Laravel 13.x
Contributed by cyppe
Part of the framework v13.35.0 release
Long-running scheduled jobs finally have a way to participate in graceful shutdowns. PR #61764 reworks how schedule:interrupt communicates: instead of writing a boolean flag that expires at the end of the minute, it now stores the moment the interrupt happened, and a new Schedule::hasBeenInterruptedSince() check lets any process — including commands detached via runInBackground() — detect that a deploy wants them to stop. Long commands can now bow out at a safe boundary instead of blocking a deploy or being killed mid-transaction.
The problem: background commands never saw the interrupt
Before this change, schedule:interrupt wrote true to the illuminate:schedule:interrupt cache key with an expiry at the end of the current minute. The sub-minute loop inside schedule:run polled that flag and stopped when it appeared, and — since #60616 — schedule:work waits for a running schedule:run to exit when it receives SIGTERM.
That chain never reached commands started with runInBackground(). Those processes are detached from the scheduler entirely: the scheduler cannot terminate them, and they had no API for learning about the interrupt. The minute-long flag lifetime made it meaningless to anything running longer than sixty seconds anyway. The practical consequence at deploy time was ugly: either the deploy waits out a 45-minute command, or something sends it a harder signal halfway through.
There was also a footgun: when repeatable (sub-minute) events were due, schedule:run cleared the interrupt signal outright, potentially wiping the flag before a background command ever read it.
What changed
Three coordinated edits:
schedule:interruptnow storesDate::now()->getTimestampMs()under the same cache key — saved withforever, so nothing expires — instead of a boolean that dies at the end of the minute. This mirrors howqueue:restartrecords its signal.SchedulegainshasBeenInterruptedSince(DateTimeInterface $time). It returnsfalsewhen interrupt polling is disabled viaSchedule::$interruptible(set bywithoutInterruptionPolling()); otherwise it reads the cache and reports whether an interrupt landed at or after the given time.ScheduleRunCommand::shouldInterrupt()now delegates tohasBeenInterruptedSince($this->startedAt), and the clear-on-repeatable special case — along withclearInterruptSignal()— is gone.
The cache key and the static $interruptible flag are unchanged. The is_numeric() guard means a leftover boolean from a pre-upgrade interrupt is simply ignored, and cache stores that return the timestamp as a string (such as Redis) work fine. No cache migration is needed.
Checking for interrupts in your own commands
Because the signal lives in the shared cache, any process can poll it — including a detached background command. Record when you started, then check at safe boundaries:
class SyncInventory extends Command
{
protected $signature = 'sync:inventory';
public function handle(Schedule $schedule)
{
$startedAt = now();
foreach ($this->batches() as $batch) {
if ($schedule->hasBeenInterruptedSince($startedAt)) {
$this->info('Interrupted — the next scheduled run continues from here.');
return;
}
$this->process($batch);
}
}
}
Scheduled in the background:
Schedule::command('sync:inventory')->hourly()->runInBackground();
Now php artisan schedule:interrupt during a deploy causes the command to finish its current batch and return; the next scheduled run picks up where it left off. schedule:run uses the exact same check against its own start time, so the sub-minute loop still stops when a fresh interrupt arrives.
Behavior changes worth knowing
The timestamp comparison makes the signal self-scoping: an interrupt only affects processes that started before it. Previously, a schedule:run that began later in the same minute inherited the flag and stopped; now it starts clean, because the stored timestamp predates its start. That is the right semantic for deploys — a run relaunched after the interrupt is a fresh start, not a straggler.
Since the timestamp is stored forever, stale values linger in the cache, but they are inert: a run only cares about interrupts newer than itself. From the operator's side, schedule:interrupt behaves exactly as before.
Upgrade impact for v13.34.0 apps
This is a drop-in upgrade:
- No code changes are required;
schedule:interruptworks identically from the CLI. - If you read the
illuminate:schedule:interruptcache key directly (rare), it now holds a millisecond timestamp rather than a boolean. Schedule::withoutInterruptionPolling()still disables everything:hasBeenInterruptedSince()returnsfalsewhen it is off.- Long-running background commands only benefit from cooperative shutdown once you add the check yourself.
Takeaways:
schedule:interruptstores a millisecond timestamp forever instead of a boolean that expires at the end of the minute.- The new
Schedule::hasBeenInterruptedSince()lets any process — including detachedrunInBackground()commands — detect interrupts that arrived after they started. schedule:runreuses the same check, so its sub-minute loop still stops on fresh interrupts but no longer inherits interrupts from earlier in the same minute.- The cache key and
Schedule::$interruptibleare unchanged; the only upgrade step is pulling the release.