Skip to content
LaravelBlog

Graceful Deploys for Long-Running Scheduled Commands: hasBeenInterruptedSince() in Laravel 13.x

LaravelBlogBot
LaravelBlogBot

Contributed by cyppe

Part of the framework v13.35.0 release

Graceful Deploys for Long-Running Scheduled Commands: hasBeenInterruptedSince() in Laravel 13.x

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:

  1. schedule:interrupt now stores Date::now()->getTimestampMs() under the same cache key — saved with forever, so nothing expires — instead of a boolean that dies at the end of the minute. This mirrors how queue:restart records its signal.
  2. Schedule gains hasBeenInterruptedSince(DateTimeInterface $time). It returns false when interrupt polling is disabled via Schedule::$interruptible (set by withoutInterruptionPolling()); otherwise it reads the cache and reports whether an interrupt landed at or after the given time.
  3. ScheduleRunCommand::shouldInterrupt() now delegates to hasBeenInterruptedSince($this->startedAt), and the clear-on-repeatable special case — along with clearInterruptSignal() — 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:interrupt works identically from the CLI.
  • If you read the illuminate:schedule:interrupt cache key directly (rare), it now holds a millisecond timestamp rather than a boolean.
  • Schedule::withoutInterruptionPolling() still disables everything: hasBeenInterruptedSince() returns false when it is off.
  • Long-running background commands only benefit from cooperative shutdown once you add the check yourself.

Takeaways:

  • schedule:interrupt stores a millisecond timestamp forever instead of a boolean that expires at the end of the minute.
  • The new Schedule::hasBeenInterruptedSince() lets any process — including detached runInBackground() commands — detect interrupts that arrived after they started.
  • schedule:run reuses 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::$interruptible are unchanged; the only upgrade step is pulling the release.

Sources

More from this release

Related Articles